作者:陈广 日期:2018-12-19
上一篇文章我们讲解了 WebSocket 在浏览器端的实现,当然,使用的完全是 JavaScript,接下来就应该讲如何在服务器端实现 WebSocket 了。所有后台语言都可以搭建 WebSocket 服务,我这只有 ASP.NET Core。所以下面我将演示如何使用 ASP.NET Core 搭建 WebSocket 服务器。
本想在上一篇文章的基础上直接创建项目的,但最后发现上篇文章的项目名称为 WebSocket,跟本文需要使用的类重名。名字没起好,干脆还是重建吧。
新建一个名为 WSDemo 的文件夹,在右键菜单上选择【Open with Code】打开此文件夹。按下【Ctrl + ~】快捷键打开终端,输入如下命令:
dotnet new empty
创建项目完成后,首先关掉 HTTPS,打开 Properties 文件夹下的 launchSettings.json 文件,将sslPort
项的值更改为0
以关闭 HTTPS。将applicationUrl
项的值更改如下:
"applicationUrl": "http://localhost:5000",
接下来将 Startup.cs 代码更改如下:
using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace WSDemo
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseWebSockets(); //加入 WebSocket 中间件
//加入处理消息中间件
app.Use(async (context, next) =>
{ //此处指定了请求 URL 的 Path 为 /ws,即完整URL为:ws://localhost:5000/ws
if (context.Request.Path == "/ws")
{
if (context.WebSockets.IsWebSocketRequest) //如果是 WebSocket 请求
{ //等待客户端的连接
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
await Echo(context, webSocket); //处理连接
}
else
{ //如果 ws://localhost:5000/ws 发送过来的并不是 WebSocket 请求,则返回400错误
context.Response.StatusCode = 400;
}
}
else
{//如果请求 URL 的 Path 部分不是 /ws,则发给下一个中间件处理
await next();
}
});
//使用静态文件,首页显示就靠它了
app.UseFileServer();
}
private async Task Echo(HttpContext context, WebSocket webSocket)
{
var buffer = new byte[1024 * 4]; //接收缓冲
//等待接收
WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
while (!result.CloseStatus.HasValue)
{ //收到的内容原样发回
await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
//再次等待接收
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
//关闭连接
await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
}
}
}
这一段代码参考了微软给出的示例代码。DotNet 2.1 版本已经内置了 WebSocket 程序包,如果您使用的 DotNet 版本没有 WebSocket,请运行以下命令安装:
dotnet add package Microsoft.AspNetCore.WebSockets --version 2.1.1
有关中间件,可以参考自由男的《Pro ASP.NET Core MVC 2(第7版》这本书的《配置应用程序》这一章,里面有详细讲解。这里我大概讲一下。如果在写程序时,有一个东西需要分很多的步骤处理,我们可以把各个步骤包装在方法内,然后在一个方法里进行统一调用。这好比早期的生产线,各个部件在生产线的不同节点进行装配,走完整个生产线后,就装配成为成品。这类生产线的缺陷是每一步只做固定的事情,从各节点的先后顺序都是事先设计好的,最终所有生产出来的东西都是一样的。
中间件的出现,使得每个节点变为模块化,你可以选择需要的模块来装配,各模块之间是随意组合的。先使用哪个模块,后使用哪个模块都可以根据需要而定。甚至于整个流程只走到一半就已经完成而退出生产线成为成品。产品按需订制,最终一条生产线生产出来的产品可以是各种各样的。这不就是活脱脱的工业 4.0、中国制造 2025 嘛。想不到工业领域还没有完全实现的东西在软件领域早已使用了。
使用 WebSocket,需要引入using System.Net.WebSockets
命名空间。然后通过在 Startup 类的Configure
方法中加入如下代码以加入 WebSocket 中间件。
app.UseWebSockets();
WebSocket 中的一些选项可供配置:
KeepAliveInterval
: 向客户端发送 “ping” 帧的频率,以确保代理保持连接处于打开状态。 默认值为 2 分钟。ReceiveBufferSize
: 用于接收数据的缓冲区的大小。 高级用户可能需要对其进行更改,以便根据数据大小进行调整,优化性能。 默认值为 4 KB。AllowedOrigins
: 用于 WebSocket 请求的允许的Origin
header 值列表。 默认情况下,允许使用所有源。如果需要更改配置,可使用以下代码:
var webSocketOptions = new WebSocketOptions()
{
KeepAliveInterval = TimeSpan.FromSeconds(120),
ReceiveBufferSize = 4 * 1024
};
app.UseWebSockets(webSocketOptions);
如果在 https://server.com 上托管服务器并在 https://client.com 上托管客户端,请将 https://client.com 添加到 AllowedOrigins
列表以验证 WebSocket。
app.UseWebSockets(new WebSocketOptions()
{
AllowedOrigins.Add("https://client.com");
AllowedOrigins.Add("https://www.client.com");
});
AcceptWebSocketAsync
方法将 HTTP 连接升级到 WebSocket 连接。对应的是上一篇文章中的 HTTP 请求中的 header:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
连接升级后,AcceptWebSocketAsync
方法将返回一个WebSocket
对象,可通过WebSocket
对象发送和接收消息。这里将WebSocket
对象传递给了Echo
方法,此方法接收消息并立即返回相同的消息。循环发送和接收消息,直到客户端关闭连接。其中,发送消息使用了SendAsync
方法,接收消息使用ReceiveAsync
。第一次在微软示例代码里到使用async
和await
处理 Socket,不容易,改天拿这套东西去改写之前文章中的 Socket 编程,看看适不适用。
既然是 Web 程序,当然需要先从服务器下载主页,然后通过主页向服务器发送请求并聊天。我们还是使用上一篇文章已经做好的主页。
在 wwwroot 文件夹下新建一个名为 Index.html 的文件,使用如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>IOT小分队</title>
</head>
<body>
<div id="echo">
<div id="echo-config" style="float: left;">
<strong>Location:</strong><br>
<input class="draw-border" id="wsUri" value="ws://localhost:5000/ws" size="35">
<br>
<p id="stateLabel" style="color:blue">Ready to connect...</p>
<button class="echo-button" id="connect">Connect</button>
<button class="echo-button" id="disconnect">Disconnect</button>
<br>
<br>
<strong>Message:</strong><br>
<input class="draw-border" id="sendMessage" size="35" value="Rock it with HTML5 WebSocket">
<br>
<button class="echo-button" id="send" class="wsButton">Send</button>
</div>
<div id="echo-log" style="float: left; margin-left: 20px; padding-left: 20px; width: 360px; border-left: solid 1px #cccccc;">
<strong>Log:</strong><br>
<textarea id="consoleLog" style="width: 350px; height: 200px; border: solid 1px #cccccc"></textarea>
<button class="echo-button" id="clearLogBtn" style="position: relative; top: 3px;">Clear log</button>
</div>
</div>
</body>
<script src='echo.js'></script>
</html>
这里唯一做的改变就是更改了存放 URI 的文本框的内容:ws://localhost:5000/ws。由于在Startup
类中使用app.UseFileServer();
加入文件服务器中间件,使得我们可以直接使用静态文件作为首页。
接下来在 wwwroot 文件夹中新建一个 echo.js 文件,输入如下代码:
var wsUri = document.getElementById('wsUri');
var stateLabel = document.getElementById("stateLabel");
var connectBtn = document.getElementById('connect');
var disconnectBtn = document.getElementById('disconnect');
var sendMessage = document.getElementById('sendMessage');
var sendBtn = document.getElementById('send');
var consoleLog = document.getElementById('consoleLog');
var clearLogBtn = document.getElementById('clearLogBtn');
var socket;
//更新状态
function updateState() {
function disable() {
sendMessage.disabled = true;
sendBtn.disabled = true;
disconnectBtn.disabled = true;
}
function enable() {
sendMessage.disabled = false;
sendBtn.disabled = false;
disconnectBtn.disabled = false;
}
wsUri.disabled = true;
connectBtn.disabled = true;
if (!socket) {
disable();
} else {
switch (socket.readyState) {
case WebSocket.CLOSED:
stateLabel.innerHTML = "Closed";
disable();
wsUri.disabled = false;
connectBtn.disabled = false;
break;
case WebSocket.CLOSING:
stateLabel.innerHTML = "Closing...";
disable();
break;
case WebSocket.CONNECTING:
stateLabel.innerHTML = "Connecting...";
disable();
break;
case WebSocket.OPEN:
stateLabel.innerHTML = "Open";
enable();
break;
default:
stateLabel.innerHTML = "Unknown WebSocket State: " + socket.readyState;
disable();
break;
}
}
}
//断开连接
disconnectBtn.onclick = function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert("socket not connected");
}
socket.close(1000, "Closing from client");
};
//发送消息
sendBtn.onclick = function () {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert("socket not connected");
}
var data = sendMessage.value;
socket.send(data);
logText("SEND:" + data);
};
//开始连接
connectBtn.onclick = function () {
logText("Connecting");
socket = new WebSocket(wsUri.value);
socket.onopen = function (event) {
updateState();
logText("Connection opened");
};
socket.onclose = function (event) {
updateState();
logText('Connection closed. Code: ' + event.code + '. Reason: ' + event.reason);
};
socket.onerror = updateState;
socket.onmessage = function (event) {
logText("RECEIVE:" + event.data);
};
};
//清除消息框内容
clearLogBtn.onclick = function () {
consoleLog.value = "";
}
//写入消息框
function logText(text) {
if (consoleLog.value == "") {
consoleLog.value += text;
} else {
consoleLog.value += "\r\n" + text;
}
consoleLog.scrollTop = consoleLog.scrollHeight
}
这段代码与使用的完全是上篇文章的 JavaScript 代码。这里不再赘述。
运行程序,结果如下图所示:
效果和上篇文章的一样,只是上篇文章我们访问的是 WebSocket 官网搭建的服务器,而这次使用的是我们自己搭建的服务器。
上面的程序直接在Startup
类的Configure
方法内,使用app.Use
编写了 WebSocket 请求的处理的逻辑。这当然不是一个好主意,实际开发中,Startup
类用于配置,不应当处理程序逻辑。那么接下来,我们就将处理逻辑封闭在中间件中,然后在Startup
中配置此中间件。
在 WsDemo 项目下新建一个名为 Infrastructure 的文件夹,并在其中新建一个名为 WsHandleMiddleware.cs 的文件,输入如下代码:
using System;
using System.Text;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System.Net.WebSockets;
namespace WSDemo.Infrastructure
{
public class WsHandleMiddleware
{
private RequestDelegate nextDelegate;
public WsHandleMiddleware(RequestDelegate next) => nextDelegate = next;
public async Task Invoke(HttpContext context)
{
if (context.Request.Path == "/ws")
{
if (context.WebSockets.IsWebSocketRequest)
{
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
await Echo(context, webSocket);
}
else
{
context.Response.StatusCode = 400;
}
}
else
{
await nextDelegate.Invoke(context);
}
}
private async Task Echo(HttpContext context, WebSocket webSocket)
{
var buffer = new byte[1024 * 4]; //接收缓冲
//等待接收
WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
while (!result.CloseStatus.HasValue)
{ //收到的内容原样发回
await webSocket.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);
//再次等待接收
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
//关闭连接
await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
}
}
}
这一次我们将中间件代码移到一个单独的文件中。内容基本没变。
打开 Startup.cs 文件,将代码修改如下:
using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using WSDemo.Infrastructure;
namespace WSDemo
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseWebSockets(); //加入 WebSocket 中间件
//加入处理消息中间件
app.UseMiddleware<WsHandleMiddleware>();
//使用静态文件,首页显示就靠它了
app.UseFileServer();
}
}
}
这一次,Startup
类的代码清爽了很多,它只做它应该做的事。
运行程序,效果和之前的一模一样。
;